'''
帖子html解析模块，使用html.parser解析页面。由于存在大量函数调用，其效率不高。
'''

import re
import math
# noinspection PyUnresolvedReferences
import html
import logging
from html.parser import HTMLParser
from html.entities import name2codepoint
from datetime import datetime
from collections import deque
# noinspection PyUnresolvedReferences
from typing import Iterable, Dict, Callable, List, Optional, Generator

__all__ = ["TagStack", "FrozenTagStack", "TiebaHTMLParser", "BaseTiebaParser",
           "FloorContent", "TopicPageParser", "ReplyContent", "FloorPageParser",
           "TiebaContent"]

# 全局常量和全局变量
TIEBA = "https://tieba.baidu.com"
logger = logging.getLogger(__name__)


class TagStack:
    '''由HTML标签组成的栈'''

    def __init__(self, iterable: Iterable = "", maxlen: int = None):
        self.stack = deque(iterable=iterable, maxlen=maxlen)

    def append(self, value):
        self.stack.append(value)

    def pop(self):
        return self.stack.pop()

    def clear(self):
        self.stack.clear()

    def startswith(self, other):
        # 在后来的实现中，发现这个方法其实用不到了，更多是用__eq__
        if not isinstance(other, TagStack):
            raise TypeError("`startswith` requires an instance of TagStack, "
                            f"not {type(other)}")
        if len(self) < len(other):
            return False
        else:
            for tag_s, tag_o in zip(self.stack, other.stack):
                if tag_s != tag_o:
                    return False
            return True

    def __iter__(self):
        yield from self.stack

    def __gititem__(self, key):
        if isinstance(key, (slice, int)):
            return self.stack[key]
        else:
            key_type = type(key).__name__
            raise TypeError(f"sequence index must be integer, not '{key_type}'")

    def __contains__(self, item):
        return item in self.stack

    def __str__(self):
        return str(self.stack).replace("deque", "TagStack")

    def __repr__(self):
        return str(self)

    def __len__(self):
        return len(self.stack)

    def __eq__(self, other):
        if isinstance(other, TagStack):
            if len(self) == len(other):
                for tag_s, tag_o in zip(self.stack, other.stack):
                    if tag_s != tag_o:
                        return False
                return True
            else:
                return False
        else:
            return NotImplemented


class FrozenTagStack(TagStack):
    '''可哈希的不可变的TagStack'''

    def __init__(self, iterable: Iterable = "", maxlen: int = None):
        if "stack" not in dir(self):
            super().__init__(iterable=iterable, maxlen=maxlen)

    def __hash__(self):
        return hash((self.stack.maxlen,) + tuple(self.stack))

    def __str__(self):
        return str(self.stack).replace("deque", "FrozenTagStack")

    # __getattribute__方法开销巨大，于是改回了下面这样的实现
    def append(self, value):
        raise TypeError("`FrozenTagStack` is immutable")

    def pop(self):
        raise TypeError("`FrozenTagStack` is immutable")

    def clear(self):
        raise TypeError("`FrozenTagStack` is immutable")

    @classmethod
    def from_tag_stack(cls, tag_stack: "TagStack") -> "FrozenTagStack":
        return cls(tag_stack.stack, maxlen=tag_stack.stack.maxlen)


class TiebaHTMLParser(HTMLParser):
    '''用于帖子页面的HTML解析器'''

    def __init__(self, tiebaparser: "BaseTiebaParser", *, convert_charrefs=True):
        super().__init__(convert_charrefs=convert_charrefs)
        self.tag_stack = TagStack()
        self._frozen_tag_stack = FrozenTagStack()
        self._tiebaparser = tiebaparser

    def handle_starttag(self, tag: str, attrs: tuple):
        tag_attr = tag
        for k, v in attrs:
            if k == "class":
                tag_attr = ".".join([tag_attr] + v.split())
        self.tag_stack.append(tag_attr)
        self._frozen_tag_stack = FrozenTagStack.from_tag_stack(self.tag_stack)

        name = self._tiebaparser.tag_stacks_dict.get(self._frozen_tag_stack)
        if name is not None:
            self._tiebaparser.handle_something("starttag", name, tag, attrs)

        logger.debug(f"Start tag: {tag}")
        for attr in attrs:
            logger.debug(f"     attr: {attr}")

    def handle_endtag(self, tag):
        name = self._tiebaparser.tag_stacks_dict.get(self._frozen_tag_stack)
        if name is not None:
            self._tiebaparser.handle_something("endtag", name, tag)

        self.tag_stack.pop()
        self._frozen_tag_stack = FrozenTagStack.from_tag_stack(self.tag_stack)
        logger.debug(f"End tag  : {tag}")

    def handle_startendtag(self, tag: str, attrs: tuple):
        name = self._tiebaparser.tag_stacks_dict.get(self._frozen_tag_stack)
        if name is not None:
            self._tiebaparser.handle_something("startendtag", name, tag, attrs)
        logger.debug(f"S&E tag  : {tag}")
        for attr in attrs:
            logger.debug(f"     attr: {attr}")

    def handle_data(self, data: str):
        data = data.replace("\xa0", " ")
        name = self._tiebaparser.tag_stacks_dict.get(self._frozen_tag_stack)
        if name is not None:
            self._tiebaparser.handle_something("data", name, data)
        logger.debug(f"Data     : {data}")

    def handle_comment(self, data: str):
        data = data.replace("\xa0", " ")
        name = self._tiebaparser.tag_stacks_dict.get(self._frozen_tag_stack)
        if name is not None:
            self._tiebaparser.handle_something("data", name, data)
        logger.debug(f"Comment  : {data}")

    def handle_entityref(self, name: str):
        c = chr(name2codepoint[name])
        logger.debug(f"Named ent: {c}")

    def handle_charref(self, name: str):
        if name.startswith('x'):
            c = chr(int(name[1:], 16))
        else:
            c = chr(int(name))
        logger.debug(f"Num ent  : {c}")

    def handle_decl(self, data: str):
        logger.debug(f"Decl     : {data}")

    def error(self, message):
        # PyCharm提示必须要实现它……
        pass


class BaseTiebaParser:
    '''帖子页面解析器的基类

    内部通过调用TiebaHTMLParser解析页面，同时当html标签栈存在于
    tag_stacks_dict中时，调用handle_something方法并传入相关内容，
    由handle_something方法根据调用的来源(name参数)，进一步分发
    给各个相关的方法进行处理。
    '''

    tag_stacks_dict = {}    # type: Dict["FrozenTagStack", str]

    def __init__(self, *, convert_charrefs=True):
        self._htmlparser = TiebaHTMLParser(self, convert_charrefs=convert_charrefs)
        self._contents = deque()
        self.pics = deque()    # type: deque[str]

    @property
    def contents(self):
        '''解析得到的内容，为FloorContent或ReplyContent'''
        return self._contents

    def handle_something(self, from_: str, name: str, *args):
        raise NotImplementedError("Method hasn't been implemented yet.")

    def feed(self, data):
        self._htmlparser.feed(data)

    @staticmethod
    def parse_image(url):
        pattern = r"https?://.*?src=(http.*)"
        match = re.search(pattern, url)
        if match is None:
            logger.warning(f"识别不到原图的url：{url}")
            return url
        return match.group(1).replace("%3A", ":").replace("%2F", "/")

    @staticmethod
    def _handle_time(data):
        pattern = re.compile(
            r"((\d+-\d+-\d+)"    # 匹配日期时间字符串的三种形式: 年-月-日
            r"|(\d+-\d+\s\d+:\d+)"    # 月-日 时间
            r"|(\d+:\d+))"            # 仅时间
        )
        match = re.search(pattern, data)

        format_list = ["{} 00:00", r"%Y-{}", r"%Y-%m-%d {}"]
        for i, fmt in enumerate(format_list, 2):
            if match.group(i):
                dtstr = datetime.strftime(datetime.now(), fmt) \
                    .format(match.group(i))
                return datetime.strptime(dtstr, r"%Y-%m-%d %H:%M")


class TiebaContent:
    '''`FloorConten`和`ReplyConten`的共同父类'''
    pass


class FloorContent(TiebaContent):
    '''保存单个楼层内容的类'''

    def __init__(self):
        self.floor = 0
        self.text = []
        self.time = None    # type: Optional[datetime]
        self.user = ""
        self.floor_id = 0
        self.total_replys = 0

    def __str__(self):
        return ("<"
                f"floor={self.floor}, "
                f"text={self.text}, "
                f"time={self.time}, "
                f"user={self.user}, "
                f"floor_id={self.floor_id}, "
                f"total_replys={self.total_replys}"
                ">")

    def __repr__(self):
        return self.__str__()

    def __lt__(self, other):
        if isinstance(other, FloorContent):
            return self.floor < other.floor
        else:
            return NotImplemented

    def __le__(self, other):
        if isinstance(other, FloorContent):
            return self.floor <= other.floor
        else:
            return NotImplemented

    def __eq__(self, other):
        if isinstance(other, FloorContent):
            return self.floor == other.floor
        else:
            return NotImplemented


class TopicPageParser(BaseTiebaParser):
    '''解析主题帖页面的类'''

    tag_stacks_dict = {
        FrozenTagStack(["html", "body", "div", "div.d", "div.i"]): "floor.text",

        FrozenTagStack(["html", "body", "div", "div.d", "div.i", "a"]): "floor.img",

        FrozenTagStack(["html", "body", "div", "div.d", "div.i", "table",
                        "tr", "td.l", "span.g", "a"]): "floor.user",

        FrozenTagStack(["html", "body", "div", "div.d", "div.i", "table",
                        "tr", "td.l", "span.b"]): "floor.time",

        FrozenTagStack(["html", "body", "div", "div.d", "div.i", "table",
                        "tr", "td.r", "a.reply_to"]): "floor.reply",
    }

    def __init__(self, *, convert_charrefs=True):
        super().__init__(convert_charrefs=convert_charrefs)
        self.current_floor = None    # type: Optional["FloorContent"]
        self.floors = deque()    # type: deque["FloorContent"]
        self.pics = deque()    # type: deque[str]
        self._contents = self.floors

    @property
    def floor_id_npages(self) -> Generator[int, None, None]:
        '''楼层对应的id，以及楼中楼共有多少页'''
        for f in self.floors:
            yield f.floor_id, math.ceil(f.total_replys / 10)

    def handle_something(self, from_: str, name: str, *args):
        if name == "floor.text":
            self.handle_text(from_, *args)
        elif name == "floor.img":
            self.handle_img(from_, *args)
        elif name == "floor.user":
            self.handle_user(from_, *args)
        elif name == "floor.time":
            self.handle_time(from_, *args)
        elif name == "floor.reply":
            self.handle_reply(from_, *args)

    def handle_text(self, from_: str, *args):
        '''处理帖子正文'''
        if from_ == "starttag":
            self.current_floor = FloorContent()
        elif from_ == "endtag":
            self.floors.append(self.current_floor)
            self.current_floor = None

        elif from_ == "data":
            data = args[0].rstrip()    # type: str
            if self.current_floor.floor:
                txt = self.current_floor.text
                if txt and isinstance(txt[-1], str):
                    txt[-1] += data
                elif data:
                    txt.append(data)
            else:
                match = re.search(r"(\d+)楼\.\s?(.*)", data)
                self.current_floor.floor = int(match.group(1))
                data = match.group(2)
                if data:
                    self.current_floor.text.append(data)

        elif from_ == "startendtag":
            tag, attrs = args
            if tag == "br":
                txt = self.current_floor.text
                if txt and isinstance(txt[-1], str):
                    self.current_floor.text[-1] += "\n"
                else:
                    txt.append("\n")
            elif tag == "img":
                src = dict(attrs)["src"]
                self.current_floor.text.append(("img", src))
                self.pics.append(src)

    def handle_img(self, from_: str, *args):
        if from_ == "startendtag":
            tag, attrs = args
            if tag == "img":
                src = self.parse_image(dict(attrs)["src"])
                self.current_floor.text.append(("img", src))
                self.pics.append(src)
        elif from_ == "data":    # 在此处添加了处理艾特的逻辑
            data = args[0].strip()
            if data.startswith("@"):
                txt = self.current_floor.text
                if txt and isinstance(txt[-1], str):
                    self.current_floor.text[-1] += data
                else:
                    txt.append(data)

    def handle_user(self, from_: str, *args):
        if from_ == "data":
            self.current_floor.user = args[0].strip()

    def handle_time(self, from_: str, *args):
        if from_ == "data":
            self.current_floor.time = self._handle_time(args[0].strip())

    def handle_reply(self, from_: str, *args):
        if from_ == "starttag":
            tag, attrs = args
            for k, v in attrs:
                if k == "href":
                    match = re.search(r"pid=(\d+)", v)
                    self.current_floor.floor_id = int(match.group(1))
        elif from_ == "data":
            data = args[0].strip()
            match = re.search(r"回复\((\d+)\)", data)
            if match is not None:
                self.current_floor.total_replys = int(match.group(1))


class ReplyContent(TiebaContent):
    '''保存单个楼中楼内容的类'''

    def __init__(self):
        self.text = []
        self.user = ""
        self.time = None    # type: Optional[datetime]
        self.floor_id = 0
        self.page = 0

    def __str__(self):
        return ("<"
                f"text={self.text}, "
                f"user={self.user}, "
                f"time={self.time}, "
                f"floor_id={self.floor_id}, "
                f"page={self.page}"
                ">")

    def __repr__(self):
        return self.__str__()


class FloorPageParser(BaseTiebaParser):
    '''解析楼中楼页面的类'''

    tag_stacks_dict = {
        FrozenTagStack(["html", "body", "div", "div.m.t", "div.i"]): "reply.text",

        FrozenTagStack(["html", "body", "div", "div.m.t",
                        "div.i", "a"]): "reply.user",

        FrozenTagStack(["html", "body", "div", "div.m.t",
                        "div.i", "span.b"]): "reply.time",

        FrozenTagStack(["html", "body", "div", "div.bc.m", "div", "div.d.h",
                        "form", "div"]): "reply.floor_id_page",
    }
    def __init__(self, *, convert_charrefs=True):
        super().__init__(convert_charrefs=convert_charrefs)
        self.current_reply = None    # type: Optional["ReplyContent"]
        self.replys = deque()    # type: deque["ReplyContent"]
        self.pics = deque()    # type: deque[str]
        self._contents = self.replys
        self.floor_id = 0
        self.page = 0

    def handle_something(self, from_: str, name: str, *args):
        if name == "reply.text":
            self.handle_text(from_, *args)
        elif name == "reply.user":
            self.handle_user(from_, *args)
        elif name == "reply.time":
            self.handle_time(from_, *args)
        elif name == "reply.floor_id_page":
            self.handle_floor_id_page(from_, *args)

    def handle_text(self, from_: str, *args):
        # TODO 与帖子TopicPageParser有较大重复，考虑优化结构减少重复代码
        if from_ == "starttag":
            self.current_reply = ReplyContent()
        elif from_ == "endtag":
            self.current_reply.floor_id = self.floor_id
            self.current_reply.page = self.page
            self.replys.append(self.current_reply)
            self.current_reply = None

        elif from_ == "data":
            data = args[0].strip()    # type: str
            # 由于贴吧更改过用户名系统，所以暂时不解析“回复 XX :”
            txt = self.current_reply.text
            if txt and isinstance(txt[-1], str):
                txt[-1] += data
            elif data:
                txt.append(data)

        elif from_ == "startendtag":
            tag, attrs = args
            if tag == "br":
                txt = self.current_reply.text
                if txt and isinstance(txt[-1], str):
                    self.current_reply.text[-1] += "\n"
                else:
                    txt.append("\n")
            elif tag == "img":
                src = dict(attrs)["src"]
                self.current_reply.text.append(("img", src))
                self.pics.append(src)

    def handle_user(self, from_: str, *args):
        '''处理艾特以及用户名'''
        if from_ == "data" and not self.current_reply.user:
            data = args[0].strip()
            if data.startswith("@"):
                txt = self.current_reply.text
                if txt and isinstance(txt[-1], str):
                    self.current_reply.text[-1] += data
                else:
                    txt.append(data)
            else:
                self.current_reply.user = data

    def handle_time(self, from_: str, *args):
        if from_ == "data":
            self.current_reply.time = self._handle_time(args[0].strip())

    def handle_floor_id_page(self, from_: str, *args):
        # 楼中楼页面需要解析它是哪一楼哪一页，否则就丢失楼中楼的来源了
        if from_ == "startendtag":
            tag, attrs = args
            if tag == "input":
                attr_dict = dict(attrs)
                if attr_dict.get("name") == "pid":
                    self.floor_id = int(attr_dict["value"])
                elif attr_dict.get("name") == "fpn":
                    self.page = int(attr_dict["value"])


def _test_speed(parser_cls: type, fname, n=100):
    import time
    with open(fname, "r", encoding="utf-8") as f:
        txt = f.read()
    s = time.perf_counter()
    parser = None    # type: Optional["BaseTiebaParser"]  # 又是pycharm
    for i in range(n):
        parser = parser_cls()
        parser.feed(txt)
    e = time.perf_counter()
    print(f"循环{n}次，共用时{(e - s):.4f}s，平均用时{((e - s) * 1000) / n:.2f}ms")
    return parser.contents


def _test_bs4(fname, n=100):
    try:
        from bs4 import BeautifulSoup
    except ModuleNotFoundError:
        return
    import time
    with open(fname, "r", encoding="utf-8") as f:
        txt = f.read()
    s = time.perf_counter()
    for i in range(n):
        b = BeautifulSoup(txt, "html.parser")
    e = time.perf_counter()
    print(f"bs4: 循环{n}次，共用时{(e - s):.4f}s，平均用时{((e - s) * 1000) / n:.2f}ms")


if __name__ == "__main__":
    from pprint import pprint
    _test_speed(TopicPageParser, "html/info.html")
    contents = _test_speed(TopicPageParser, "html/p0001.html")
    pprint(contents)
    contents = _test_speed(FloorPageParser, "html/22616319749_2.html")
    pprint(contents)
